歡迎來到第七章!本章總共有兩篇內容:
這些主題的核心功能,並非由 Django Ninja 實作,但框架仍提供了一定程度的整合。並且,這些功能對於任何 Django 專案來說,都至關重要。
本文介紹幾乎所有 API 專案都需要的——身分認證(Authentication)。
我們將探討如何在 Django Ninja 中利用 Django 內建的 session-based 認證,實現完整的登入驗證功能,並進一步說明如何設定全域認證,以減少程式碼的重複。
本文所有的程式碼改動,可參考這個 PR。
進入實作前,我們要先了解,所謂的身分認證,究竟代表什麼。
以「帳號密碼 + session 認證」為例,身分認證的範圍主要涵蓋兩個階段。
首先,當使用者透過帳號密碼進行登入時,系統會檢查這些內容、確認身分合法。登入成功後,系統會將使用者資訊(比如用戶 id)儲存至 session,以維持登入狀態。
這是登入時的認證,也是我們最常說的認證。(狹義的認證)
接著,當使用者嘗試存取受「認證保護」的 API 時,系統會檢查 session 並確認身分,確保每個 API 請求都來自合法登入的使用者。
簡言之:
兩個層次相輔相成、一體兩面,確保服務能夠在使用者登入和後續操作中,提供適當的安全保障。
了解了上述兩個層次後,我們要先來實作「狹義」的認證——也就是登入驗證本身。
我們將建立一個「使用者登入」API,並直接透過 Django 的authenticate
和login
函式處理帳號密碼驗證和登入狀態——非常方便!
authenticate
用來驗證使用者輸入的帳號(username
)和密碼是否正確,login
則將使用者的登入狀態儲存至 session。
先新增一個登入請求 Schema:
# user/schemas.py
class LoginRequest(Schema):
username: str = Field(examples=['Alice'])
password: str = Field(examples=['password123'])
然後是 view 函式:
from django.contrib.auth import authenticate, login
from user.schemas import CreateUserRequest, LoginRequest
...
@router.post('/users/login/', summary='登入使用者')
def login_user(
request: HttpRequest, payload: LoginRequest
) -> dict[str, str]:
"""
登入使用者
"""
user = authenticate(
request,
username=payload.username,
password=payload.password
)
if user is not None:
login(request, user) # 將使用者登入狀態儲存至 session
return {'message': '登入成功'}
else:
raise HttpError(401, '帳號或密碼錯誤')
非常簡單!
附帶一提,我不太喜歡程式中有「不必要」的else
,此時的寫法仍不盡理想——因為else
完全可以省略。
在最新的程式碼中,你可以看到我已改成:
user = authenticate(...)
if user is None:
raise HttpError(401, '帳號或密碼錯誤')
login(request, user) # 將使用者登入狀態儲存至 session
return {'message': '登入成功'}
這樣的做法即所謂的 Guard Clause 或 Early Return(雖然這裡是 raise)。
authenticate
和login
的用法幾乎是固定的,很容易理解:
authenticate
在驗證成功時會 return 對應的User
物件,失敗時則返回None
。login
不會 return,但request
和user
為必要的參數。成功登入後,你會得到 200 回應,並獲得兩組 cookie:
這對於 API client(比如 Postman)使用者很重要,畢竟瀏覽器會自動幫你存,但這些工具可不會——好吧,我錯了,至少我用的 RapidAPI 會自動存儲、發送!
(我測試 API 時還覺得奇怪,怎麼認證防護都失效了🤣)
如果工具沒有幫你做,記得自己在請求的 headers 加上:
POST /users/2/avatar/ HTTP/1.1
...
Cookie: csrftoken=...; sessionid=...
X-CSRFToken: ...
authenticate
預設是以AbstractUser
的username
欄位和密碼作為認證基準,如果想用別的欄位,比如email
,則要自己覆寫 Django 的認證後端。
登入功能完成後,接下來要將「需要登入才能存取」的 API,分別加上認證保護,使用 Django Ninja 提供的django_auth
——這是專門給 Django 內建的 session 認證使用。
我們以「上傳 avatar」API 為例:
from ninja.security import django_auth
...
@router.post(
path='/users/{int:user_id}/avatar/',
summary='上傳 avatar',
auth=django_auth # 加上這組參數
)
這個例子中,auth=django_auth
確保只有「已登入的使用者」才能存取此 API,否則將得到 401 或 403 回應。
但你可能會想到:
光是驗證「已登入」還不夠吧?
「上傳 avatar」應該只能幫「自己」上傳,總不能幫「別人」上傳大頭照吧!
沒錯,所以我們在 view 函式內部,還要多一層驗證。
request.user
傳統的 Django 專案,我們會透過函式的第一參數——request
,用request.user
來獲得當前使用者資訊,比如:(參考文件)
if request.user.is_authenticated:
# Do something for authenticated users.
...
else:
# Do something for anonymous users.
...
具體來說:
request.user
會是一個User
實例,代表當前登入的使用者。request.user
則是一個AnonymousUser
實例,代表未登入使用者。當使用者已登入,我們可以檢查request.user
的屬性,比如request.user.id
,來確認是否為「本人」。
request.auth
但寫 Django Ninja 則需要使用它提供的request.auth
,實作結果如下:
...
def upload_avatar(...) -> dict[str, str]:
"""
上傳 avatar
"""
# 檢查登入的使用者是否為「本人」
if request.auth.id != user_id:
raise HttpError(403, '無權限上傳其他使用者的 avatar')
...
測試一下,登入後在 URL path 打別人的 id 來呼叫此 API:
// 403 Forbidden
{
"detail": "無權限上傳其他使用者的 avatar"
}
非常好!
雖然這裡用request.auth
來取代request.user
,但其實兩者的內涵有很大的不同。
在 Django Ninja 中,request.auth
代表的是認證流程 return 的結果。此外,Django Ninja 允許你自定義認證方法,所以request.auth
的內容是不固定的。
讓我們深入了解一下。
request.auth
包含了當前認證方法返回的值。
User
物件、字串、Python 字典等等。request.auth
是 Django 的User
物件。request.auth
可能是 API key 本身或與之相關的資訊。request.auth
可能包含解碼後的 token 資訊。總之,只要記得,想在 view 函式內進一步取得認證資訊,要透過request.auth
。
這樣就已經實作完認證了,但我們可以讓事情更「簡單」一點。
一一對每個 API 設定認證保護,感覺有點繁瑣——尤其在 API 多的時候。
對此,Django Ninja 支援全域認證,讓所有 API 預設都直接受到保護,開發者只需在特定路由中進行例外處理,排除不想套用的 API 即可。
實作上非常簡單,Django Ninja 直接提供了SessionAuth
認證類別,用來處理全域的 session-based 認證。
SessionAuth
在專案的api.py
中加入下面內容:
# NinjaForum/api.py
from ninja.security import SessionAuth
...
api = NinjaAPI(
auth=SessionAuth(), # 設定全域認證
...
)
如此一來,全部的 API 都預設擁有認證保護,你可以在特定 API 中排除,比如「登入使用者」:
@router.post(path='/users/login/', summary='登入使用者', auth=None)
在路由裝飾器中,把auth
定義為None
,解除認證保護。
我們來測試一下「有認證保護」的 API,你會發現在未登入的情況下,嘗試不同 HTTP 方法的 API,你將會得到不同的錯誤回應:
所以前面才會說你會得到「401 或 403」回應。
在我們的專案設計中,只有登入的使用者才能存取「取得所有使用者」API。
未登入的情況下,你會得到 401 回應:
// 401 Unauthorized
{
"detail": "Unauthorized"
}
未登入也無法存取「新增文章」API——這顯然非常合理,否則文章不就沒作者了😅
你會得到 403 回應:
// 403 Forbidden
{
"detail": "CSRF check Failed"
}
你心想:「奇怪?為什麼是 CSRF check Failed?」
這是 Django 的 CSRF 保護機制,因為我們的 API 是 POST 方法,所以 Django 會自動檢查 CSRF token,但我們沒有提供 CSRF token,所以就會出現這個錯誤。
在這篇文章中,我們探討了 Django 的 session 認證與 Django Ninja 的整合,實作了「使用者登入」API,並為其他 API 加上認證保護。最後還示範了如何實現全域認證,讓整個流程更加簡單。
這個系列的最後實踐,我們要來為專案——寫測試!
下一篇將探討,如何使用 test client 和 pytest 來為我們的 Django API 撰寫單元測試。這不僅能幫助我們驗證現有功能,還能為未來的開發和重構提供多一層的保障。
本文同步發表於我的部落格——Code and Me